Skip to content

[HIP] [JIT] fp8_mqa_logits: hand-written gfx950 prefill indexer kernel - #5046

Open
sumin-hong wants to merge 1 commit into
ROCm:mainfrom
moreh-dev:sumin/hip-fp8-mqa-logits-gfx950
Open

sumin-hong wants to merge 1 commit into
ROCm:mainfrom
moreh-dev:sumin/hip-fp8-mqa-logits-gfx950

Conversation

@sumin-hong

@sumin-hong sumin-hong commented Aug 27, 2026

Copy link
Copy Markdown

Validation status: PR kernel commit d046a14c3b, rebased onto ROCm/aiter@9aa8a6b9.
The original op-level sweep below was measured before that rebase. The separately reported GLM-5.2-MXFP4 E2E and accuracy results are now included below under GLM-5.2-MXFP4 E2E and accuracy (2026-09-05 update), using vLLM 3ff4f02df and AITER 0.1.21.post1 plus this PR's files.

Motivation

The DeepSeek-V3.2 / GLM-5 "lightning indexer" produces the sparse-attention
selection logits. For each query row m and KV position n:

logits[m, n] = sum_h relu(<Q[m, h, :], K[n, :]>) * kv_scale[n] * weights[m, h]
               for n in [cu_seqlen_ks[m], cu_seqlen_ke[m]), -inf elsewhere

In the prefill path the caller has already gathered K out of the paged cache into
a contiguous [N, 128] buffer with a separate [N] fp32 scale, so there is no
block table and no preshuffle. This PR adds a hand-written HIP kernel for that
path on gfx950 (CDNA4), alongside the existing Triton/Gluon
aiter.ops.triton.attention.fp8_mqa_logits. The paged decode half is #5047.

cu_seqlen_ks is a request's base offset into the gathered K buffer and
cu_seqlen_ke grows by one per query row, so each request is a causal triangle
and several requests arrive packed into one [M, N] chunk. Chunks are sized so
M*N*4 stays under VLLM_SPARSE_INDEXER_MAX_LOGITS_MB, which makes a 32K
request (4096, 32768) and a 128K one (1024, 131072).

Technical Details

New op: aiter/ops/fp8_mqa_logits.py

fp8_mqa_logits is a drop-in for the Triton entry point -- same tensors
(q_fp8, k_fp8, kv_scale, weights, cu_seqlen_ks, cu_seqlen_ke) and the
same clean_logits semantics. Key design points:

  • No LDS staging. K is streamed HBM->register and contracted 32 heads x 32
    tokens per mfma_scale_f32_32x32x64_f8f6f4 tile.
  • BLOCK_M query rows share one K stream, so K is read once per row block
    rather than once per row. Grid is (ceil(M / BLOCK_M), SplitN); SplitN
    splits each block's KV tile range so a small row grid still fills the device.
  • Warps split the tile range, not the rows. That keeps several independent K
    streams in flight per block. Splitting rows across warps instead -- so K is read
    once per BLOCK_M * NUM_WARPS rows rather than once per BLOCK_M -- measured
    1.5-23% slower: the latency hiding is worth more than the L2 traffic it saves.
  • Paired-row v_permlane32_swap_b32 head reduce. v_permlane32_swap_b32 a, b
    leaves a's reduction in lanes 0-31 and b's in lanes 32-63, so two query rows
    reduce with one swap and one add, and the store that follows uses all 64 lanes
    instead of half.
  • kv-scale hoist. kv_scale >= 0 and ReLU is positive-homogeneous, so the
    scale is applied once per KV column after the head reduction rather than inside
    it.
  • Row groups dispatched high-m first for N > 2048. Under causal masking a
    row group's work grows with m, so in natural order the longest-running blocks
    are dispatched LAST and become the tail; reversing starts them first and lets
    the short ones fill in behind. Worth +6% at (4096, 4096) and +3% at
    (8192, 8192), neutral elsewhere, and free -- it only reorders block dispatch.
  • Fused -inf fill. With clean_logits the kernel writes the -inf outside
    each row's window itself. The Triton path pre-fills all M*N elements with
    torch.full and then overwrites the valid ones, paying for the valid region
    twice.
  • -fno-honor-nans for the module, so the ReLU is a single v_max_f32.
    Without it LLVM must assume a signalling NaN and emits an IEEE canonicalize
    first -- two VALU per accumulator value, which is ~27% of the kernel's VALU.

BlockM, SplitN, num_warps, unroll2 and reverse_rows are all tunable;
zero means "use the host heuristic".

The kernel is gfx950-only and fixed at n_heads=32, head_dim=128 -- the shipped
GLM-5-FP8 indexer shape. is_supported(num_heads, head_dim) gates on that, so a
caller that also serves other shapes can route them to the Triton kernel rather
than trip a TORCH_CHECK. This mirrors how _should_use_asm_kernel gates the
head_size=128-only ASM paged-attention kernel in aiter/ops/attention.py.

Files added / changed:

  • aiter/ops/fp8_mqa_logits.py -- the op and its support gate
  • csrc/kernels/fp8_mqa_logits.cu -- kernel and host dispatch
  • csrc/include/fp8_mqa_logits.h, csrc/pybind/fp8_mqa_logits_pybind.cu
  • csrc/include/rocm_ops.hpp, aiter/jit/optCompilerConfig.json -- module_fp8_mqa_logits
  • op_tests/test_fp8_mqa_logits.py -- correctness + perf sweep

Test Plan

op_tests/test_fp8_mqa_logits.py runs Triton and HIP on identical inputs and
grades both against one fp32 torch reference -- the same ref_fp8_mqa_logits the
Triton lane's test uses. Gates are an exact -inf mask match plus
calc_diff < 1e-3 and checkAllclose; tolerances are not widened.

The sweep is the cartesian product of 14 (s_q, s_k) shapes,
num_heads in {32,64,128}, head_dim in {64,128}, clean_logits in {0,1} and
six window modes -- 900 cases, 150 of which the HIP kernel supports. Points worth
calling out:

  • Six window modes. Beyond causal and cp, the sweep covers misaligned,
    empty (rows with cu_ends below zero or below cu_starts), past_end
    (bounds beyond seq_len_kv) and multi_req (several requests packed into one
    chunk, so cu_starts jumps at each boundary and a block's rows straddle it).
    All are legal indexer input at a chunk boundary, and all are where the masking
    and the -inf fill are easiest to get wrong. Grading the HIP kernel under
    past_end caught two out-of-bounds writes during development, both on the
    clean_logits=False path.
  • NaN-poisoned output. The output buffer is filled with NaN before each call,
    so a position the kernel fails to write fails the -inf mask check. Without it
    the check is close to vacuous -- the caching allocator hands back a block a
    previous case already left holding the correct -inf.
  • The real chunk shapes: (4096, 32768), (2048, 65536), (1024, 131072).
    The reference runs in query-row chunks so its [heads, s_q, s_k] score tensor
    stays bounded (unchunked it is 17 GiB at heads=32, s_q=1024, s_k=131072).
  • Cases the HIP kernel does not support leave its columns nan rather than
    reporting a wrong-but-fast number, and any case dropped for lack of memory is
    logged by name so a short table cannot read as full coverage.
python3 op_tests/test_fp8_mqa_logits.py

Test Result

All correctness gates pass on gfx950 across the sweep; per-case hip err matches
triton err. Grading the HIP kernel under past_end caught two out-of-bounds
writes on the clean_logits=False path (a cu_starts past seq_len_kv left the
fill's first range unclamped, and the store bounded abs_pos only by cu_ends);
both are fixed here.

Performance on MI355x/gfx950, num_heads=32, head_dim=128, run_perftest on an
otherwise idle GPU. causal is one request per chunk, multi_req is four:

s_q s_k clean_logits window Triton µs HIP µs speedup
1024 1024 True causal 15.7 11.8 1.33x
4096 4096 True causal 88.1 68.5 1.29x
8192 8192 True causal 216.6 223.5 0.97x
4096 32768 True causal 1063.6 613.4 1.73x
2048 65536 True causal 1039.5 632.6 1.64x
1024 131072 True causal 1608.2 820.6 1.96x
128 32768 True causal 225.1 35.2 6.39x
671 131072 True causal 1791.9 491.5 3.65x
4096 32768 True multi_req 349.2 266.6 1.31x
2048 65536 True multi_req 332.1 278.7 1.19x
1024 131072 True multi_req 510.1 294.3 1.73x
128 32768 True multi_req 63.5 21.6 2.94x
671 131072 True multi_req 410.7 191.4 2.15x
8192 8192 True multi_req 95.2 114.6 0.83x

Summarised over the 32-shape sweep (both clean_logits settings, both windows):

group cases geomean range HIP faster
causal 16 1.79x 0.87x - 6.39x 13 / 16
multi_req 16 1.31x 0.67x - 3.23x 11 / 16
clean_logits=True 16 1.64x - 13 / 16
clean_logits=False 16 1.43x - 11 / 16
all 32 1.53x 0.67x - 6.39x 24 / 32

The win tracks s_k / s_q, which is what the shared-K-stream design predicts:
BLOCK_M rows amortise one K read, so the longer a row's KV range is relative to
the row grid, the more there is to amortise. Every shape with s_k >= 8 * s_q is
a win (1.19x - 6.39x), and the largest is the small-M/long-KV corner
(128, 32768) at 6.4x, where the row grid alone cannot fill the device and
SplitN does the work.

The losses are the square, short-KV shapes -- (8192, 8192) at 0.97x/0.83x and
(1024, 1024)/(4096, 4096) under clean_logits=False -- where the tile loop is
short relative to the per-block Q/weights prologue. That prologue is also what
pins the kernel at 2 waves/SIMD: BLOCK_M=4 needs 194 VGPR, of which Q and the
per-row weights are 128, and both scale with BLOCK_M, so KV reuse and register
pressure cannot be traded apart in this design (BLOCK_M=8 needs 256 VGPR and
spills 113). Staging K through LDS would decouple them; until then
is_supported() plus a shape check lets a caller keep Triton on that corner.

clean_logits=True is the better case for the HIP kernel (1.64x vs 1.43x), which
is the fused -inf fill showing up: the Triton path pays a torch.full over all
s_q * s_k elements before the kernel overwrites the valid ones.

Related Work

This PR proposes a structurally different, fixed-shape HIP backend that streams K directly from HBM to registers and shares the K stream across query rows. A direct head-to-head comparison with the active alternatives and quantitative roofline utilization remain pending.

Current Validation

  • Rebased onto upstream main at 9aa8a6b91f1972952314bd16176a76d392f6b85c with no overlapping-file conflicts.
  • black==26.3.0 --check: pass for the added Python op and test.
  • ruff==0.16.0 check: pass for the added Python op and test.
  • python3 -m py_compile: pass for the added Python op and test.
  • A standalone op-suite rerun on the rebased head, direct competing-PR A/B, and quantitative roofline analysis remain pending. The supplied model-level integration report is included below.

AI assistance: OpenAI Codex was used for rebase adaptation, formatting, static checks, overlap research, and PR drafting. The submitter reviewed the resulting commit.

GLM-5.2-MXFP4 E2E and accuracy (2026-09-05 update)

The following supplied experiment report addresses the E2E and accuracy request in #5046 (comment). These are reported measurements on the integration stack specified below; this documentation update did not rerun the experiments.

E2E and accuracy for this PR on GLM-5.2-MXFP4 / MI355X (gfx950) / TP4, per @nholmber's request.

Headline: 1.07x output throughput at 128k input, at every concurrency point, with accuracy held.
No measurable change at 1k input, which is expected — this is a prefill kernel and 1k prefill is a
small share of E2E work.

Setup

model amd/GLM-5.2-MXFP4 @ 386bd0e4, kv-cache-dtype=fp8_e4m3, MTP=0
vLLM built from source at upstream main 3ff4f02df (0.28.1rc1.dev398)
aiter amd-aiter 0.1.21.post1 + this PR's files, JIT module module_fp8_mqa_logits
serve AMD's GLM-5.2 MXFP4 doc command, --tensor-parallel-size 4, --linear-backend aiter --moe-backend aiter
bench vllm bench serve --dataset-name random, --backend openai + /v1/completions, --random-range-ratio 0.0, --ignore-eos

Integration — one env-gated branch in rocm_fp8_mqa_logits()
(vllm/v1/attention/ops/rocm_aiter_mla_sparse.py), since this kernel is a drop-in for the
aiter.ops.triton.attention.fp8_mqa_logits that vLLM already calls there. GLM-5.2 is
index_n_heads=32, index_head_dim=128, so is_supported() holds and the kernel really engages.

Both arms ran concurrently on one node, one 4-GPU half each, so they share wall-clock and
thermals; the only difference is the kernel:

arm prefill indexer logits kernel
before aiter.ops.triton.attention.fp8_mqa_logits (the reference AITER kernel)
after aiter.ops.fp8_mqa_logits (this PR)

The branch raises rather than silently falling back, and the server log confirms which kernel
each arm used: 4 provenance lines on the "after" arm (one per TP rank), 0 on "before". So the
"after" column cannot be an accidental re-measurement of "before".

128k in / 1k out

conc out tok/s before after speedup mean TTFT ms before after p50 TTFT ms before after mean TPOT ms before after
4 81.5 87.2 1.07x 22,100 20,098 23,103 21,049 27.41 26.17
8 94.6 100.8 1.07x 31,790 29,249 26,235 24,370 53.38 50.66
16 102.4 109.2 1.07x 49,416 45,204 24,645 21,863 107.63 101.98
32 104.9 112.2 1.07x 123,775 115,082 90,815 84,363 173.28 162.35
64 105.2 112.5 1.07x 356,040 332,399 411,419 385,218 189.69 177.65

TTFT 1.07-1.13x better, TPOT 1.05-1.07x better.

Independent confirmation from wall-clock (not derived from the same JSON metrics) — each
vllm bench serve invocation, before -> after:

conc 4 8 16 32 64 sweep total
before 113s 189s 343s 665s 1317s 2627s
after 107s 179s 323s 624s 1236s 2469s
-5.3% -5.3% -5.8% -6.2% -6.1% -6.0%

1k in / 1k out

conc 4 8 16 32 64 128 256
out tok/s before 320.9 566.0 913.5 1438.6 2215.6 3345.7 4802.2
out tok/s after 319.7 561.5 885.8 1404.9 2211.8 3341.0 4782.1
speedup 1.00x 0.99x 0.97x 0.98x 1.00x 1.00x 1.00x
mean TPOT before 12.23 13.74 16.82 21.17 27.30 35.88 50.10
mean TPOT after 12.27 13.84 17.17 21.51 27.32 35.93 50.25

Per-run wall-clock is identical to within a second at 5 of 7 points.

TTFT is omitted for this scenario: at conc 16-64 the "after" arm's points happened to run while the
other arm was loading its 408 GB of weights, and that host-side NVMe/CPU burst shows up in TTFT
(0.78-0.85x) while leaving throughput and TPOT at 1.00x. The clean points either side (conc 4, 8,
128, 256) are all 1.00x on TTFT too, so we read the dip as contention, not kernel. Happy to re-run
the 1k sweep synchronised if the TTFT column matters.

Accuracy

Validation gates and expectations from the AMD "GLM-5.2 MXFP4 - vLLM status and validation" deck.

gate before after expected
coherence (17x23) PASS, 391 PASS, 391 coherent, clean </think>, 391
GPQA-Diamond (198 q, max_tokens=100k, temp 1.0 / top_p 0.95) 90.91% 90.40% ~92%+
RULER niah_single_2 @ 64k (500 samples) 100% 100% ~90%+
RULER niah_single_2 @ 128k (500 samples) 100% 100% ~90%+

GPQA differs by one question out of 198 (0.5 pp), well inside the +-2.1 pp standard error at that
sample size. RULER is at the ceiling on both arms at both lengths, i.e. the sparse path still selects
correctly with the new kernel.

Notes

  • Why 1.07x E2E when the op-level geomean is 1.53-1.79x: the indexer prefill logits kernel is one
    component of E2E time, so its speedup is diluted by everything else in the step. A ~6-7% E2E gain at
    long context from a single kernel is the expected shape of this result, and we'd suggest not setting
    E2E expectations from the op-level numbers.
  • Why a prefill kernel moves TPOT by 5-7%: with chunked prefill, prefill chunks are interleaved
    with decode batches, so faster prefill means decode steps queue behind it for less time. This effect
    only shows up E2E.
  • Nominal vs actual concurrency at 128k: weights are ~102 GB/GPU at TP4, leaving ~157 GB for KV at
    default utilisation, and this model costs ~55 KB/token — about 21 requests of 132k. So conc 32 and 64
    queue rather than running fully in parallel, which is why throughput saturates at ~105-112 tok/s from
    conc 16 on. Identical on both arms, so the comparison holds, but the concurrency label is nominal.
  • Prefix caching disabled (--no-enable-prefix-caching). With it on, a repeated prompt skips
    prefill entirely, which would hide exactly what this PR changes. This does mean absolute numbers are
    not directly comparable to runs that leave it at the default.
  • Not compared against [FlyDSL] gfx950 FP8 MQA logits indexer kernel #4538 or [Triton/Gluon] [gfx950] add an optimized prefill fp8_mqa_logits for H64D128 #5048. @nholmber also asked for those; this run was scoped to
    before/after against the reference AITER kernel. We can add [FlyDSL] gfx950 FP8 MQA logits indexer kernel #4538 (the FlyDSL implementation of the
    same kernel) as a third arm if it would help the decision.

Submission Checklist

Add a hand-written HIP implementation of the prefill FP8 MQA indexer for
gfx950 alongside the existing Triton/Gluon implementation.

The kernel streams K directly from HBM to registers, shares each K stream
across a block of query rows, reduces 32 heads with permlane operations, and
fuses the out-of-window negative-infinity fill. The host dispatch exposes the
main launch parameters while providing defaults for the supported
32-head, 128-dimensional shape.

Add a correctness and performance sweep covering causal, context-parallel,
misaligned, empty, past-end, and packed multi-request windows.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Sumin Hong <sumin.hong@moreh.io>
@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every PR:

  • ✅ Pre-checks (submodule verification, code formatting)
  • ✅ Aiter op tests (gfx942 + gfx950)
  • ✅ Triton tests on MI35X (only when aiter/ops/triton/** or related paths are changed)

Extended tests (opt-in via labels):

Label Tests
ci:gfx1250-ffm-triton Run the five-shard gfx1250 FFM Triton test suite
ci:triton-300x Run an additional Triton test job on MI300X in PRs; main branch always runs both MI35X and MI300X
multigpu Aiter multi-GPU tests on the 8-GPU runner
ci:sglang SGLang integration tests: DeepSeek-R1-MXFP4 accuracy, Qwen 3.5 accuracy
ci:atom ATOM benchmark: DeepSeek-R1-0528, GPT-OSS-120B
ci:atom_full ATOM accuracy suite for PR and main models from ATOM models_accuracy.json
ci:vllm vLLM benchmark: GPT-OSS-120B, DeepSeek-R1-0528, Kimi-K2.5
ci:all All standard extended tests (excludes ci:atom_full)

Only add ci:atom_full for FlyDSL or Triton upgrades.
Add labels via the sidebar or gh pr edit 5046 --add-label <label>

PR title tags:
Component tags ([Triton/Gluon], [HIP], [CK], [ASM], ...) are added to the PR title automatically from the changed files and re-synced on every push — change-type tags like [fix]/[Perf] and op tags like [MLA] are left untouched. Add the no-auto-title label to opt this PR out of title tagging.

@sumin-hong
sumin-hong marked this pull request as ready for review August 27, 2026 09:58
@sumin-hong
sumin-hong requested review from a team and a lite review from Copilot August 27, 2026 09:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new gfx950-only, fixed-shape (n_heads=32, head_dim=128) hand-written HIP backend for the prefill fp8_mqa_logits operator, plus JIT build integration and a correctness/performance sweep to compare against the existing Triton/Gluon implementation.

Changes:

  • Add a new JIT-compiled HIP op (aiter.ops.fp8_mqa_logits) and pybind module for prefill FP8 MQA logits on gfx950.
  • Implement the gfx950 kernel + host dispatch path in C++/HIP with tunable launch parameters and optional fused -inf fill.
  • Add an op_tests/ sweep that validates masking/error metrics against the shared fp32 reference and reports performance.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
op_tests/test_fp8_mqa_logits.py New correctness + perf sweep comparing Triton vs HIP against a shared fp32 reference across shapes/windows.
aiter/ops/fp8_mqa_logits.py New Python entry point for the JIT-compiled HIP op plus an is_supported() gate for gfx950 + (32,128).
csrc/kernels/fp8_mqa_logits.cu New gfx950 HIP kernel and torch-facing dispatch entry point.
csrc/include/fp8_mqa_logits.h Public C++ declaration for the new op entry point.
csrc/pybind/fp8_mqa_logits_pybind.cu Pybind module definition exposing fp8_mqa_logits.
csrc/include/rocm_ops.hpp Adds the FP8_MQA_LOGITS_PYBIND macro to register the op with pybind.
aiter/jit/optCompilerConfig.json Registers the new JIT module and applies -fno-honor-nans for the kernel TU.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +373 to +381
const int M = q_fp8.size(0);
const int N = k_fp8.size(0);

TORCH_CHECK(q_fp8.size(1) == NUM_HEADS && q_fp8.size(2) == HEAD_SIZE,
"Only n_heads=32, head_dim=128 supported");
TORCH_CHECK(k_fp8.size(1) == HEAD_SIZE, "K must be [N, 128]");
TORCH_CHECK(q_fp8.is_contiguous() && k_fp8.is_contiguous(),
"q_fp8 and k_fp8 must be contiguous");

@zufayu
zufayu requested a review from amd-ruitang3 August 29, 2026 09:54
@stefanskiasan

Copy link
Copy Markdown

Results from a production-style deployment: GLM-5.3 full model (own Quark checkpoint, MXFP4 experts, FP8 block-scaled attention) on 4× MI355X (gfx950), TP4, ROCm 7.2.3, vLLM glm-release fork + ROCM_AITER_MLA_SPARSE, MTP k=3, FP8 KV, kernel JIT-built on top of the AITER 0.1.19 wheel and dispatched from rocm_fp8_mqa_logits (opt-in env switch on our side).

  • op_tests/test_fp8_mqa_logits.py: all 900 configurations pass against the torch reference on our GPU.
  • Prefill of a 128k-token prompt (vllm bench serve, single request, output 1): 10 449 → 13 473 tok/s (+29 %) on the same image with the tuned a8w8 blockscale GEMMs.
  • Long prompts, where the indexer dominates (needle prompt incl. a 900-token answer): 150k tokens 10.7 → 8.7 s, 400k 64 → 31 s, 900k (738k tokens) 493 → 126 s (3.9×); all needles correct, perplexity unchanged (1.3188 vs 1.3197), throughput at 32 concurrent users 2k/256: 620 → 645 tok/s. With the old path a 120k-token context started answering after 12.4 s, now after 8.7 s.
  • Works with the vLLM default block size 16 (no page-geometry dependency, unlike the paged decode half in [HIP] [JIT] fp8_paged_mqa_logits: hand-written gfx950 decode indexer kernel #5047), so it went straight into production for us.

Thanks for the kernel. Happy to run further shapes or a rebased version if useful.

@nholmber

nholmber commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Could you provide e2e performance (say 1k/1k conc4-256 powers of 2 and 128k/1k conc4-64) and accuracy data (esp. long-context & reasoning like GPQA Diamond + Needle-in-Haystack) comparing performance against the reference AITER kernel? Would also recommend comparing against #4538 and #5048

@sumin-hong

Copy link
Copy Markdown
Author

Could you provide e2e performance (say 1k/1k conc4-256 powers of 2 and 128k/1k conc4-64) and accuracy data (esp. long-context & reasoning like GPQA Diamond + Needle-in-Haystack) comparing performance against the reference AITER kernel? Would also recommend comparing against #4538 and #5048

I have attached the additional experiment results to the PR description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants